# 上传文件简单实现
30 秒速记
- 链路:表单
enctype="multipart/form-data"提交 →Koa路由 →busboy拆流 →fs.createWriteStream落盘 → 返回JSON - 前端表单少了
enctype,服务端只能收到文件名,收不到文件内容 - 存储名服务端生成,客户端文件名只用来展示,防路径穿越和重名覆盖
- 写流
finish才算落盘成功;任何error都要删掉半截文件 - 扩展名能伪造,类型校验看文件头字节(
magic bytes),比如用file-type库
最简单的上传就是表单提交 multipart 请求,服务端用 busboy 把文件流 pipe 到磁盘,再返回结果。 这条链路十几行就能跑通,但离能上线还差几步:大小和数量限制要配,不然一个请求就能把磁盘写满;文件名要服务端生成,客户端传个 ../../app.js 进来就麻烦了;成功要以写流 finish 为准,不能看读流 end;类型不能只信后缀,要读文件头几个字节判断。我一般会先写到临时目录,校验通过再 rename 到正式目录。
理解「上传文件简单实现」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 filesystem 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
下面是一个可以直接替换的 uploadFile,基于 busboy 1.x,把限制、命名、成功判定和清理都补齐了。
const fs = require('fs')
const path = require('path')
const { randomUUID } = require('crypto')
const busboy = require('busboy')
const ALLOWED = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }
function uploadFile(ctx, { dir }) {
fs.mkdirSync(dir, { recursive: true }) // Node 10.12+ 自带递归创建
return new Promise((resolve, reject) => {
let bb
try {
bb = busboy({
headers: ctx.req.headers,
defParamCharset: 'utf8',
limits: { fileSize: 5 * 1024 * 1024, files: 3, fields: 10 },
})
} catch (err) {
return reject(Object.assign(err, { status: 415 }))
}
const fields = {}
const tasks = [] // 每个文件一个 Promise,全部 finish 才算成功
const written = [] // 出错时要删的文件
bb.on('field', (name, val) => { fields[name] = val })
bb.on('file', (name, file, { filename, mimeType }) => {
const ext = ALLOWED[mimeType]
if (!ext) return file.resume() // 不要的也必须读掉
const saveTo = path.join(dir, `${randomUUID()}.${ext}`)
written.push(saveTo)
const out = fs.createWriteStream(saveTo)
tasks.push(new Promise((ok, fail) => {
file.on('limit', () => fail(Object.assign(new Error('文件过大'), { status: 413 })))
out.on('finish', () => ok({ field: name, originalName: filename, path: saveTo }))
out.on('error', fail)
file.on('error', fail)
}))
file.pipe(out)
})
bb.on('close', async () => {
try {
const files = await Promise.all(tasks)
resolve({ success: true, fields, files })
} catch (err) {
written.forEach((p) => fs.rm(p, { force: true }, () => {}))
reject(err)
}
})
bb.on('error', reject)
ctx.req.pipe(bb)
})
}
module.exports = { uploadFile }
调用方在路由里 try { ctx.body = await uploadFile(ctx, { dir }) } catch (e) { ctx.status = e.status || 500 }。这里的 mimeType 来自客户端声明,只用来做第一道筛选;真正对外展示前,还要用 file-type 读文件头再确认一次。
面试官追问
追问 1前端表单忘了写 enctype,提交后后端会收到什么?
默认是 application/x-www-form-urlencoded,文件 input 只会提交文件名字符串,内容根本没上传。busboy 解析出来只有一个普通字段,file 事件不会触发。
追问 2用户把文件名改成 ../../index.js 上传,会有什么风险?
如果直接 path.join(uploadDir, filename),结果会跳出上传目录,可能覆盖服务代码。所以存储名一律用 crypto.randomUUID() 加白名单后缀生成,原始文件名只存数据库。
追问 3生产环境上传 5MB 的图片直接返回 413,Node 代码里根本没限制,怎么回事?
多半是前面的 Nginx 拦了,client_max_body_size 默认是 1m。在对应 location 里调大,并且和 busboy 的 limits.fileSize 保持一致,别一层放一层收。
追问 4只允许上传图片,检查后缀是 .png 够吗?
不够,后缀是用户随便改的。要读文件开头的字节,PNG 是 89 50 4E 47,JPEG 是 FF D8 FF,可以用 file-type 库判断;要显示给别人看的图片,最好再用 sharp 重新编码一遍,顺带把夹带的内容洗掉。
追问 5为什么建议先写临时目录,再移到正式目录?
上传中途断开、超限、校验不过都会留下半截或非法文件,在临时目录里删就行,不会被静态服务提前对外暴露。校验通过后同一个文件系统内 fs.rename 是原子的,正式目录里永远只有完整文件。
# 上传文件简单实现
# 依赖模块
30 秒速记
- 第三方只有一个
busboy,负责拆multipart流 - 其余都是
Node内置:fs写文件、path拼路径、util.inspect打日志、os可以拿os.tmpdir()放临时文件 util.inspect(val)只适合打日志,拿它存字段值会多出一对引号- 手写递归建目录可以换成
fs.mkdirSync(dir, { recursive: true })
这个上传 demo 的依赖很轻:第三方只有 busboy,其他全是 Node 内置模块。 busboy 拆请求流,fs 负责创建目录和写文件,path 负责拼出跨平台的路径,util.inspect 用来把字段值打印得好看一点。有两个地方可以顺手改:inspect 会把字符串包上引号,'tom' 存进结果里前端拿到的就是带引号的值,存字段要直接用 val;手写的 mkdirsSync 递归函数在 Node 10.12 之后可以用 fs.mkdirSync(dir, { recursive: true }) 一行替代。
理解「依赖模块」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 module 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
面试官追问
追问 1返回的 formData.picName 是 "'cat'",多了一对单引号,哪来的?
result.formData[fieldname] = inspect(val) 这句。util.inspect 是给日志用的,会把字符串格式化成带引号的形式。存值直接写 val,打日志才用 inspect。
追问 2代码里 require('os') 了但没用上,它本来能干什么?
最常见的用法是 os.tmpdir()
